RustでHello World with Emulator
Introduction
今回は、Rustで実装したファミコン(NES)エミュレータでここにある
HelloWorldロムを動作させてみましょう。
Information
エミュレータを実装する際に参考にしたサイトです。
NESエミュレータやNES自体に関しては情報が多いのでとてもとても助かりました。
- Writing NES Emulator in Rust
- Nes Dev
- NES研究室
- Qiita:ファミコンエミュレータの創り方 - Hello, World!編 -
- speakerdeck:ファミコンエミュレータの創り方
とくにWriting NES Emulator in Rustは、
各デバイスを順番に実装していき、その途中のソースコードも
すべてGithubにあるのでとても有用です。
コードもってくればそのまま普通に動きます。
本稿で説明している内容についての詳細は、
↑のサイトをみれば説明されているので
ご参照ください。
About Family Computer
ファミコンとは
言わずもがな、ファミコン(任天堂ファミリーコンピュータ)とは
1983年に任天堂から発売された家庭用ゲーム機です。
私が子供のころはじめてやった思い出深いゲーム機でもあります。
そして、NESとは海外で発売された
ファミコンの名称(Nintendo Entertainment System、略してNES)です。
詳しくはwikipediaなどをご確認ください。
エミュレータとは
仮想化技術の1つであり、特定のハードウェアや
OSに対して開発されたソフトウェアを、
本来とは異なる環境で擬似的に
動作させるためのソフトまたはハードを指します。
VirtualBoxなんかは自分のマシン上で別のOSを起動したりするので
エミュレータですね。
ファミコンのエミュレータは実機発売から30年以上たっていて情報も多く、
(比較的)実装しやすいようです。
ハード&ソフトの構造
まずはハードの構成を確認してみましょう。
ファミコン本体を超簡易的な図で示すと下記のようになります。
次にエミュレータで実装する各モジュールの
簡単な説明です。
CPUとAPU
ファミコンのCPUは、リコー製のRP2A03で、
6502(MOS Technology社の開発したCPU)をベースにカスタムされたものです。
クロック数は1.79MHz。
このモジュールの仕事は、メインプログラムの命令を実行することです。
カートリッジからプログラムを読み取ってプログラムを実行し、
各デバイスへアクセスします。
なお、CPUが直接操作できるのはレジスタとメモリマップだけになってます。
そして、サウンドを管理するのがAPU(Audio Processing Unit)です。
APUは2A03の一部として実装されています。
今回のHelloWorldではサウンドは
関係ないので実装しません。
PPU(Picture Processing Unit)
グラフィックを管理するモジュール。
リコー製の2C02チップをベースにしており、
このモジュールはゲームの状態を画面に描画する役割を持っています。
ファミコンではVRAMなどの内容をもとに
バックグラウンド(BG)とスプライト(Sprite)の
2つのレイヤでグラフィックを表示させています。
BGは背景、Spriteはオブジェクトに使われることが多いみたいです。
PPUはCPUの3倍のクロックで動作するので、
エミュレータを実装するときはこのサイクルを
考慮する必要があります。
今回のHelloWorldサンプルでも、
一部PPUの実装が必要です。
カートリッジ
ファミカセです。
カートリッジにはグラフィックデータとゲームプログラム、
2つのROMチップが搭載されています。
エミュレータでは、iNESフォーマットとよばれる形式の
ダンプファイルを使用します。
iNESの詳細やパース方法については後述。
エミュレータの動作ロジック
では、NESエミュレータがどのように動作するか簡単に説明します。
ざっくりというと↓のような動きです。
- SDL2でウィンドウの作成
- カートリッジのロード
- プログラムカウンタからopcodeを取得して 命令を判別、対象のアドレスを算出して命令実行
- PPUによって描画処理実行
- 3に戻る
カートリッジから読み込んだデータをもとに
CPU命令を実行します。
そして画像データの描画も実施。
そしてまた次の命令を取得して実行・・・と繰り返していきます。
Try implementation
では実装していきましょう。
コードはポイントになるところを解説していきます。
今回作成したコードはここにあります。
※本稿で載せているコードはかなり端折っているのでご了承ください
ROM
エミュレータで使用するROMは、カートリッジ内容をダンプしたファイルを使用します。
今回はメジャーなiNESフォーマットを使用します。
iNESフォーマットは主に次の3つから構成されます。
- ヘッダー
- プログラムROM
- キャラクターROM
ROMのヘッダーの0-3バイトは
「NES^Z」(hex: $4E $45 $53 $1A , dec : 78, 69, 83, 26)
固定になっています。
4バイト目と5バイト目にROMのブラックサイズが記述されています。
そして、ゲームプログラムが格納されたROM、
8×8のゲーム画像が格納されたキャラクターROMと続きます。
ヘッダーのパースは↓のような感じで行います。
pub struct Header { pub nes_header_const: [u8; 4], pub program_size: u32, pub char_size: u32, } impl Header { //buf : ROMファイルのBuffer pub fn new(buf: &Vec<u8>) -> Result<Self, Error> { let headers = *array_ref!(buf, 0, 4); match headers { [78, 69, 83, 26] => Ok(Header { nes_header_const: headers, program_size: (buf[4] as u32) * 0x4000, char_size: (buf[5] as u32) * 0x2000, }), _ => { ・・・・・・・・・・・ } } } }
プログラムROMとキャラクターROMのパースは以下のイメージ。
const NES_HEADER_SIZE: usize = 0x10; pub struct Rom { pub header: Header, pub program_data: Vec<u8>, pub char_data: Vec<u8>, ・・・・・ } impl Rom { //ROMファイル全体のパース pub fn load(path: &str) -> Result<Self, io::Error> { //read Rom file let rom_buffer = load_file(path); //ヘッダー let nes_header = Header::new(&rom_buffer.to_vec())?; //プログラムデータ let program_data = load_program(&rom_buffer, &nes_header)?; //キャラクターデータ let char_data = load_char(&rom_buffer, &nes_header)?; ・・・・・・・ Ok(Rom { header: nes_header, program_data, char_data, ・・・・・・・ }) } } //プログラムデータ取得 fn load_program(buffer: &[u8], header: &Header) -> Result<Vec<u8>, std::io::Error> { let start: usize = NES_HEADER_SIZE; let end = start + header.program_size as usize; Ok(buffer[start..end].to_vec()) } //キャラクターデータ取得 fn load_char(buffer: &[u8], header: &Header) -> Result<Vec<u8>, std::io::Error> { let start: usize = NES_HEADER_SIZE + header.program_size as usize; let end = start + header.char_size as usize; Ok(buffer[start..end].to_vec()) }
これでROMファイルの読み込みができたので、プログラムデータをCPUに読ませていきます。
ちなみに、ここで読ませたhello_world.nesのキャラクターデータ(着色済み)は↓です。
とてもなつかしい感じ。
CPU
次はCPUの実装です。
opコードを取得して命令とアドレッシングモードを判定して実行していきます。
例えば取得した命令コードが「a9」だったとします。
リファレンスを見ると
a9は LDA #$NN、アドレッシングモードはImmediateなので、
- レジスタ(アキュームレータ)に値をロード
- Immediate(opコードの次にあるデータを値として扱う)
- ステータスレジスタ(zero flag,negative flag)の更新
といった処理をするということがわかります。
このあたりの詳細については下記サイトをご確認ください。
CPUレジスタ
RP2A03はレジスタを6つ持っています。
プログラムカウンタは16bitでそれ以外は8bit。
演算に使えるレジスタは1つだけ(アキュムレータ)です。
名前 | サイズ(bit) | 内容 |
---|---|---|
A | 8 | アキュムレータ |
X | 8 | インデックスレジスタ |
Y | 8 | インデックスレジスタ |
S | 8 | スタックポインタ |
P | 8 | ステータスレジスタ |
PC | 16 | プログラムカウンタ |
下記はステータスレジスタ。
さきほどLDA命令でZとかNとか書いてましたが、
それのことです。
bit | name | detail | contents |
---|---|---|---|
bit7 | N | ネガティブ | 演算結果のbit7が1の時にセット |
bit6 | V | オーバーフロー | P演算結果がオーバーフローを起こした時にセット |
bit5 | R | 予約済み | 常にセットされている |
bit4 | B | ブレークモード | BRK発生時にセット、IRQ発生時にクリア |
bit3 | D | デシマルモード | 0:デフォルト、1:BCDモード (未実装) |
bit2 | I | IRQ禁止 | 0:IRQ許可、1:IRQ禁止 |
bit1 | Z | ゼロ | 演算結果が0の時にセット |
bit0 | C | キャリー | キャリー発生時にセット |
レジスタは以下のように実装します。
bitflagsマクロについてはここで説明してます。
bitflags! { pub struct CpuFlags: u8 { const CARRY = 0b00000001; const ZERO = 0b00000010; const INTERRUPT_DISABLE = 0b00000100; const DECIMAL_MODE = 0b00001000; const BREAK = 0b00010000; const BREAK2 = 0b00100000; const OVERFLOW = 0b01000000; const NEGATIV = 0b10000000; } } pub struct Cpu<'a> { pub reg_a: u8, pub reg_x: u8, pub reg_y: u8, pub reg_sp: u8, pub status: CpuFlags, pub reg_pc: u16, ・・・ }
アドレッシングモード
CPU命令がどの値を操作の対象にするのかを知るため、
そのアドレスを算出する方法です。
つまり、CPU命令の次の1バイト/2バイトをどのように扱うか定義するプロパティです。
6502のアドレッシングモードはImmediateやZeroPageなど複数あります。
これによってどこどこのアドレスの値を演算対象にしろ、とか
opコードの次にあるアドレスの値を使え、とかがわかります。
アドレッシングモードの説明はここが詳しいので参考にしてください。
AddressingMode判定部分のサンプルコードは以下のイメージです。
//cpu/cpu.rs ///AddressingMode判定 fn get_operand_address(&mut self, mode: &AddressingMode) -> u16 { match mode { AddressingMode::Immediate => self.reg_pc, AddressingMode::ZeroPage => self.mem_read(self.reg_pc) as u16, ・・・・・・ AddressingMode::NoneAddressing => { panic!("mode {:?} is not supported", mode); } } }
CPUを実行させる部分のサンプルコードです。
プログラムカウンターからコードを取り出し、命令を実行していきます。
アドレッシングモードによってどこの値を用いるか判定しています。
//cpu/cpu.rs //LDA fn lda(&mut self, mode: &AddressingMode) { let addr = self.get_operand_address(mode); let value = self.mem_read(addr); self.set_reg_a(value); } ///CPU実行 pub fn run_with_callback<F>(&mut self, mut callback: F) where F: FnMut(&mut Cpu), { let opcodes: &HashMap<u8, &'static opcodes::OpCode> = &(*opcodes::OPCODES_MAP); loop { if let Some(_nmi) = self.bus.poll_nmi_status() { self.interrupt(interrupt::NMI); } let code = self.mem_read(self.reg_pc); self.reg_pc += 1; let program_counter_state = self.reg_pc; //OpCode取得 let opcode = opcodes .get(&code) .unwrap_or_else(|| panic!("OpCode {:x} is not recognized", code)); match code { 0xa9 | 0xa5 | 0xb5 | 0xad | 0xbd | 0xb9 | 0xa1 | 0xb1 => { self.lda(&opcode.mode); } ・・・・・・ _ => todo!(), } //busのcyclesを進める self.bus.tick(opcode.cycles); //program counterを進める if program_counter_state == self.reg_pc { self.reg_pc += (opcode.len - 1) as u16; } ・・・・・・・ } } ・・・
こんな感じで6502の命令をすべて実装していきます。
実装する際はここなどを参考にしてみてください。
PPU
あとはPPUを実装して画像表示に関する部分を実装します。
今回のHelloWorldを実現するだけなら、
PPUの一部実装だけでOKです。
画面の描画は、下記の流れでおこないます。
- ネームテーブル(表示キャラ番号を配置)の情報を参照
- パターンテーブル(表示キャラパターンを保存)からデータを貼り付け
- 属性テーブル(キャラに着色する配置を指定)から情報を取得
- 3の情報にパレットテーブル(画面に使うパレット)の情報で着色して表示
PPUの構造体は以下のように定義。
pub struct Ppu { ///ROMに保存されているゲームのビジュアル pub char_data: Vec<u8>, ///画面で使用されるパレットテーブルを保持するための内部メモリ pub palette_table: [u8; 32], ///背景情報を保持するための2KiBのスペースバンク pub vram: [u8; 2048], ///スプライトの状態を保持するための内部メモリ pub oam_data: [u8; 256], ///ミラーリング pub mirroring: Mirroring, /// Address Register pub addr: AddrRegister, // Control Rregister pub ctrl: ControlRegister, /// Aask Register pub mask: MaskRegister, /// Status Register pub status: StatusRegister, /// Scroll Register pub scroll: ScrollRegister, pub oam_addr: u8, internal_data_buf: u8, ///ライン scanline: u16, ///PPUサイクル cycles: usize, ///NMI pub nmi_interrupt: Option<u8>, }
このPPU構造体に対して各種レジスタへのread/write処理を実装します。
※ppu/ppu.rs参照
このPPUを使って描画処理を行います。
※render.rsのrender参照
ゲームのメインループ
これまで実装したCPUやPPUを組み合わせて一連の処理が行われるように実装します。
nes.rsではイベントループ処理とPPUを使った画面描画を
Bus(モジュール間を繋ぐ経路)のコールバックとして定義し、
CPUを実行します。
//nes.rs pub fn run<'a>(rom:Rom,mut canvas:Canvas<Window>, mut event_pump:EventPump,mut texture:Texture<'a>,mut frame:Frame) { //BusとLoop処理 let bus = Bus::new(rom, move |ppu: &Ppu| { render::render(ppu, &mut frame); texture.update(None, &frame.data, 256 * 3).unwrap(); //画面を描画 canvas.copy(&texture, None, None).unwrap(); //画面を更新 canvas.present(); //イベント処理 for event in event_pump.poll_iter() { match event { Event::Quit { .. } | Event::KeyDown { keycode: Some(Keycode::Escape), .. } => std::process::exit(0), _ => { } } } }); //CPUエミュレート let mut cpu = Cpu::new(bus); cpu.reset(); cpu.run(); }
main.rs(プログラムのエントリポイント)では、
SDL2を使ったゲームウィンドウの作成、ROMのパース
とNesのrun関数を実行します。
fn main() { //SDL初期化 let sdl_context = sdl2::init().unwrap(); // Videoサブシステム取得 let video_subsystem = sdl_context.video().unwrap(); //Wdnow作成 let window = video_subsystem .window("NES Example", 500, 400) .position_centered() .build() .unwrap(); //Canvasの作成 let mut canvas = window.into_canvas().present_vsync().build().unwrap(); canvas.set_scale(3.0, 3.0).unwrap(); //ゲームのループ let event_pump = sdl_context.event_pump().unwrap(); //Texture作成 let creator = canvas.texture_creator(); let texture = creator .create_texture_target(PixelFormatEnum::RGB24, 256, 240) .unwrap(); //Frame作成 let frame = Frame::new(); //ROM読み出し let args: Vec<String> = env::args().collect(); let nes_file = &args[1]; let rom = Rom::load(nes_file).unwrap(); //NESの実行 nes::run(rom, canvas, event_pump, texture, frame); }
実行してみる
では実行してみましょう。
ここにある、helloworld_cversion.zipをダウンロードし、
解凍してhello_world.nesをもってきます。
そしてcargo runで実行。
% cargo run /path/your/hello_world.nes Compiling nes-rs v0.1.0 (/path/your/nes-rs) Finished dev [unoptimized + debuginfo] target(s) in 7.60s Running `target/debug/nes-rs hello_world.nes` read rom file Header { nes_header_const: [78, 69, 83, 26], program_size: 32768, char_size: 8192 }
長い道のりでしたが、ようやくHello Worldが表示されました。
Summary
NESエミュレータとはいえ、
HelloWorldくらいなら簡単だろと思ってましたがけっこう大変でした。
サウンドとかラスタスクロール機能は必要ありませんが、
CPUやグラフィックの一部は実装が必要だしROMのパースも必要なので、
いままで書いたHelloWorldの中で一番高い難易度でした。
今回エミュレータを実装するにあたって、
いろいろなサイトやコードを参考にさせてもらいましたが、
それらを見たり、実装することがとても勉強になりました。
※ハード寄りの実装経験がほとんどないので
また、(制限が厳しかったとはいえ)リソースを効率よく使うことについて
ソフト側もハード側も工夫を凝らしています。
最近は必要に応じてリソースがスケールアウトするのが当たり前になってますが、
そもそもリソースを無駄に消費してないか?とか
メモリをGCだよりで適当に使ってない?とか
改めて考え、今後に活かしていきたいと思います。